Skip to content

Fix 500s and empty results from binary fields in Calcite pushdowns - #5767

Merged
ahkcs merged 2 commits into
opensearch-project:mainfrom
cnoramut:fix/binary-pushdown-500
Sep 23, 2026
Merged

ahkcs merged 2 commits into
opensearch-project:mainfrom
cnoramut:fix/binary-pushdown-500

Conversation

@cnoramut

Copy link
Copy Markdown
Contributor

Description

source=idx | sort bin on a binary field returns a 500 whose reason is a generic Failed to fetch data from the index. The real cause is visible only in details, where it reads IllegalArgumentException[Can't load fielddata on [bin] because fielddata is unsupported on fields of type [binary]]. Nine commands fail this way. A tenth, where isnotnull(bin), returns 200 with zero rows against documents that all have the field populated.

A binary field has neither fielddata nor doc values, so OpenSearch cannot bucket or sort on it. The plan-time guards ask whether a type is atomic, not whether it is aggregatable. Binary("binary", ExprCoreType.UNKNOWN) reads as atomic, passes every guard, and reaches the shard. geo_point becomes GEOMETRY, fails the same test, and is already rejected as a 400 by #5751. binary is not.

The filter case fails differently, and silently. existsQuery(bin) is valid DSL, but BinaryFieldMapper indexes nothing when doc_values is false, not even a _field_names entry, so exists has no term to match and the shard honestly reports zero hits for a correctly-formed query.

This refuses a binary reference where a field reference resolves, so each pushdown declines and Calcite keeps an un-pushed plan that returns correct results.

  • PredicateAnalyzer.NamedFieldExpression.getReference() and getReferenceForTermQuery(), the seam every affected family already routes through. The filter path re-analyzes the predicate as a _source script and stays pushed down, while the aggregate and sort paths decline the planner rule.
  • AbstractCalciteIndexScan, the two field-sort sites. The script-sort branch beside them needs no guard, since it reads from _source.
  • AggregateAnalyzer, the dedup sort hint, which carries a raw field name that never reaches the accessors above.
  • RexStandardizer, route a binary field to _source rather than doc values, which is what makes the filter redirect work.

Refusing in the accessors rather than in the NamedFieldExpression constructors is deliberate. A top_hits fetch field only needs getRootName(), and BinaryFieldMapper.BinaryFieldType.valueFetcher returns SourceValueFetcher.identity, so the fields API serves a binary field from _source and that request shape was always valid. A constructor-level refusal would decline dedup on any index whose mapping merely contains a binary field, even when the query never names it, costing a pushdown that works correctly today, 0.0075s pushed down against 0.254s declined over 50000 documents. The last case in the test file pins this.

Before

"reason":  "Failed to fetch data from the index: the background task failed or interrupted."
"details": "... IllegalArgumentException[Can't load fielddata on [bin] because fielddata is
            unsupported on fields of type [binary]. Use doc values instead.]"
"status":  500

After, the same query, HTTP 200

"schema":   [{"name": "host", "type": "string"}, {"name": "bin", "type": "binary"}]
"datarows": [["host-a", "Y210"], ["host-a", "Y211"], ["host-b", "Y212"], ["host-b", "Y213"]]
"total":    4

Behaviour on a binary field, measured on a live cluster before and after.

Query Before After
sort bin 500, fielddata 200, 4 rows in order
stats count() by bin 500 200, 4 buckets
stats max(bin) 500 200, the real maximum
top 2 bin, rare 2 bin, dedup bin 500 200
timechart span=1m count() by bin, chart count() over m by bin 500 200
xyseries m bin IN ('x','y') c 500 200
sort bin | dedup m 500 200
where isnotnull(bin) 200, zero rows 200, all 4 rows
sort latency | fields bin 200 200, unchanged

One behaviour change beyond the reported symptom. The RexStandardizer change applies to every script context, so a pushed-down script referencing a binary field previously read null from doc values and now reads the real base64 value. eval x = concat(bin, 'a') returns a value where it used to return null.

That also moves where a bad script fails. A comparison against a binary field is now compiled on the shard against the real value, so where bin = 'zzz' returns a QueryShardException for a script it cannot compile rather than the earlier fielddata error. Nothing regresses, but the error text changes.

graphLookup on a binary edge field is the one caller these accessors do not protect, and it was already broken. CalciteEnumerableGraphLookup.queryLookupTable resolves the edge field at execution time outside any decline path, so the refusal escapes as a 500 instead of declining a rule. It returned a 500 before this change as well, since the terms query it emitted on a binary field fails at the shard, so the only difference is that the message now names the field. Verified on a live cluster, a keyword edge returns rows and a binary edge returns 500 either side of the change. graphLookup is marked experimental and a base64 blob is not a plausible graph edge, so this is recorded, not fixed.

An alias field whose path is a binary field looks unguarded and is not. instanceof OpenSearchBinaryType is false for the OpenSearchAliasType such a mapping produces, but Calcite resolves the alias at plan time to the base field's input ref, so the explain shows payload_alias=[$1] where $1 is payload, and the name reaching the guard is never the alias. Verified on a live cluster.

Out of scope, noted in the issue. Comparing a binary field against a string still fails, and not only for =. where bin = 'zzz' and where bin != 'zzz' report Cannot cast "java.lang.String" to "org.apache.calcite.avatica.util.ByteString", where bin > 'a' reports no applicable SqlFunctions.gt overload, and where match(bin, 'zzz') cannot work at all against a field OpenSearch does not index. Every one of those fails with pushdown disabled too, so declining the pushdown exposes the coercion gap rather than causing it, and the family belongs with #5753. What this fixes on the filter side is the null predicates, isnotnull(bin) returning all populated rows instead of none and isnull(bin) returning none, which is what the test file covers.

Also, stats count() by bin declines rather than taking the scripted _source route AggregateAnalyzer already uses for a text field with no .keyword. That costs a full scan, measured at 0.42s against 0.17s for a keyword group key over the same 50000 documents, so roughly 2.5x on a different cardinality rather than a like-for-like comparison. Worth it against a 500, but worth reclaiming. A blanket null return is not the way, because it would make max(bin) build a top_hits with no sort and return an arbitrary document, so keeping that pushdown needs the value-source site separated from the sort sites.

Related Issues

Resolves #5757

Testing

  • integ-test/src/yamlRestTest/resources/rest-api-spec/test/issues/5757.yml, 19 cases over HTTP against a real binary mapping, covering all three pushdown families plus the xyseries aggregate-filter-argument route and the dedup sort hint. 18 assert answers. The last asserts a plan, because the accessor-versus-constructor placement above returns identical correct rows either way and is invisible to an answer assertion.
  • Both new guards were confirmed to fail with their fix reverted and to pass with it restored. Removing the AggregateAnalyzer dedup-hint check fails only the dedup sort-hint case. Moving the refusal into the constructors fails only the plan case.
  • RelJsonSerializerTest.testSerializeAndDeserializeUDT changes one expected value, the script source for the binary field, from DOC_VALUE to SOURCE. That flip is the unit-level assertion of the RexStandardizer change and of the behaviour change noted above, so it is the point of the edit rather than a fixup around it.
Suite Result
:opensearch:test 1711 tests, 0 failures, 3 skipped
:integ-test:yamlRestTest -Dtests.rest.suite=issues/5757 19/19 pass, 0 skipped
CalciteExplainIT, pushdown and no-pushdown variants 532 tests, 0 failures, 84 skipped
spotlessCheck clean

Verified against a local 3.9.0-SNAPSHOT tarball, single node and single shard, with plugins.calcite.enabled and plugins.calcite.pushdown.enabled both true. Not tested multi-shard or with security enabled.

Check List

  • New functionality includes testing.
  • New functionality has been documented.
  • New functionality has javadoc added.
  • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed.
  • API changes companion pull request created.
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.
For more information on following Developer Certificate of Origin and signing off your commits, please check here.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

PR Reviewer Guide 🔍

(Review updated until commit 55f67f5)

Here are some key observations to aid the review process:

🧪 PR contains tests
🔒 No security concerns identified
✅ No TODO sections
🔀 No multiple PR themes
⚡ No major issues detected

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

PR Code Suggestions ✨

Latest suggestions up to 55f67f5

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null safety check

Add a null check before the instanceof check to prevent potential
NullPointerException if the field type is not found in the map. This ensures
robustness when dealing with fields that might not exist in fieldTypes.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [632-635]

-if (helper.fieldTypes.get(key.field()) instanceof OpenSearchBinaryType) {
+ExprType fieldType = helper.fieldTypes.get(key.field());
+if (fieldType instanceof OpenSearchBinaryType) {
   throw new AggregateAnalyzer.AggregateAnalyzerException(
       String.format("Cannot push down a dedup sort on binary field [%s]", key.field()));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException if helper.fieldTypes.get(key.field()) returns null. Adding a null check before the instanceof check improves robustness, though the impact depends on whether null field types are expected in practice.

Medium
Prevent null pointer exception

Store the result of getFieldTypes().get() in a variable and add a null check before
the instanceof check. This prevents potential NullPointerException when the field
type is not found and improves code readability.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/AbstractCalciteIndexScan.java [453-458]

-if (osIndex.getFieldTypes().get(digest.getFieldName()) instanceof OpenSearchBinaryType) {
+ExprType fieldType = osIndex.getFieldTypes().get(digest.getFieldName());
+if (fieldType == null || fieldType instanceof OpenSearchBinaryType) {
   if (LOG.isDebugEnabled()) {
     LOG.debug("Cannot pushdown the sort on binary field {}", digest.getFieldName());
   }
   return null;
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies a potential NullPointerException when getFieldTypes().get() returns null. Storing the result in a variable and adding a null check improves both safety and readability, making the code more defensive against missing field types.

Medium
Handle missing field types

Add a null check for fieldType before the instanceof check to handle cases where the
field might not exist in the type map. This prevents potential NullPointerException
and ensures the method returns null safely for unknown fields.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/AbstractCalciteIndexScan.java [396-402]

 ExprType fieldType = osIndex.getFieldTypes().get(fieldName);
-if (fieldType instanceof OpenSearchBinaryType) {
+if (fieldType == null || fieldType instanceof OpenSearchBinaryType) {
   if (LOG.isDebugEnabled()) {
     LOG.debug("Cannot pushdown the sort on binary field {}", fieldName);
   }
   return null;
 }
Suggestion importance[1-10]: 6

__

Why: The suggestion adds a null check for fieldType before the instanceof check. While this prevents potential NullPointerException, the existing code already returns null when the field is binary, so adding fieldType == null extends this behavior to missing fields, which may or may not be the intended behavior.

Low

Previous suggestions

Suggestions up to commit d4f1715
CategorySuggestion                                                                                                                                    Impact
General
Increase log visibility level

The null return when encountering a binary field in sort expressions may silently
disable pushdown optimization without user awareness. Consider logging at WARN level
instead of DEBUG to make this limitation more visible, especially since it affects
query performance.

opensearch/src/main/java/org/opensearch/sql/opensearch/storage/scan/AbstractCalciteIndexScan.java [453-458]

 if (osIndex.getFieldTypes().get(digest.getFieldName()) instanceof OpenSearchBinaryType) {
-  if (LOG.isDebugEnabled()) {
-    LOG.debug("Cannot pushdown the sort on binary field {}", digest.getFieldName());
-  }
+  LOG.warn("Cannot pushdown the sort on binary field {}", digest.getFieldName());
   return null;
 }
Suggestion importance[1-10]: 5

__

Why: Changing from DEBUG to WARN level could improve visibility of this performance-affecting limitation. However, this is a subjective logging level decision, and the current DEBUG level may be intentional to avoid log noise in production environments.

Low
Suggestions up to commit 8223f3e
CategorySuggestion                                                                                                                                    Impact
Possible issue
Add null check for field type

The field type lookup may return null if the field doesn't exist in the mapping. Add
a null check before the instanceof check to prevent NullPointerException when
processing dedup sort keys on non-existent fields.

opensearch/src/main/java/org/opensearch/sql/opensearch/request/AggregateAnalyzer.java [632-635]

-if (helper.fieldTypes.get(key.field()) instanceof OpenSearchBinaryType) {
+ExprType fieldType = helper.fieldTypes.get(key.field());
+if (fieldType instanceof OpenSearchBinaryType) {
   throw new AggregateAnalyzer.AggregateAnalyzerException(
       String.format("Cannot push down a dedup sort on binary field [%s]", key.field()));
 }
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that helper.fieldTypes.get(key.field()) could return null if the field doesn't exist in the mapping, which would cause the instanceof check to safely return false but leaves the code vulnerable to potential issues. However, the instanceof operator already handles null safely (returns false), so while extracting to a variable improves code clarity and enables future null handling, it doesn't prevent an actual NullPointerException. The suggestion is valid for defensive programming and code maintainability.

Medium

@codecov

codecov Bot commented Sep 14, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 13.63636% with 19 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.24%. Comparing base (a29cf85) to head (55f67f5).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...nsearch/storage/scan/AbstractCalciteIndexScan.java 0.00% 8 Missing ⚠️
...rch/storage/scan/CalciteEnumerableGraphLookup.java 0.00% 4 Missing ⚠️
...arch/sql/opensearch/request/AggregateAnalyzer.java 0.00% 3 Missing ⚠️
...arch/sql/opensearch/request/PredicateAnalyzer.java 50.00% 2 Missing and 1 partial ⚠️
.../sql/opensearch/storage/serde/RexStandardizer.java 0.00% 0 Missing and 1 partial ⚠️

❌ Your project check has failed because the head coverage (63.24%) is below the target coverage (99.00%). You can increase the head coverage or adjust the target coverage.

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #5767      +/-   ##
============================================
- Coverage     63.24%   63.24%   -0.01%     
- Complexity     8820     8824       +4     
============================================
  Files           938      938              
  Lines         40211    40237      +26     
  Branches       4530     4538       +8     
============================================
+ Hits          25432    25446      +14     
- Misses        13957    13966       +9     
- Partials        822      825       +3     
Flag Coverage Δ
sql-engine 63.24% <13.63%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

A binary field has neither fielddata nor doc values, but the plan-time
guards test whether a type is atomic rather than aggregatable, so
OpenSearchBinaryType passes them and reaches the shard. Nine PPL commands
fail there with a 500, and where isnotnull returns 200 with zero rows
because BinaryFieldMapper writes no _field_names entry for exists.

Refuse a binary reference in NamedFieldExpression.getReference and
getReferenceForTermQuery, in the two field-sort sites of
AbstractCalciteIndexScan, and in the AggregateAnalyzer dedup sort hint.
Those pushdowns decline and Calcite returns correct rows un-pushed, while
the filter path re-analyzes as a _source script. RexStandardizer routes a
binary field to _source, so a pushed-down script over one now reads its
real base64 value instead of null from doc values.

Signed-off-by: Chayanin Noramuttha <cnoramut@gmail.com>
@cnoramut
cnoramut force-pushed the fix/binary-pushdown-500 branch from 8223f3e to d4f1715 Compare September 21, 2026 21:52
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit d4f1715

* top_hits} fetch field is served from {@code _source} by the fields API and so is valid.
*/
private static void rejectBinaryField(String name, ExprType type) {
if (type instanceof OpenSearchBinaryType) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The binary-field guards check the raw resolved type with instanceof OpenSearchBinaryType. That misses alias fields: an alias pointing at a binary field resolves to OpenSearchAliasType, not OpenSearchBinaryType, so it slips past every guard and runs into the exact bugs this PR fixes — isnotnull(alias) silently returns 0 rows, and sort alias fails with a 500.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this on a cluster and the alias case is already covered.

You are right that the flattened type map holds OpenSearchAliasType under the alias key, and the guards are never handed that key. OpenSearchTypeFactory.convertSchema omits alias fields from the TableScan row type and CalciteRelNodeVisitor re-adds each one as a project over its target, so the name reaching fieldTypes.get is already payload and the check fires.

Measured raw beside alias, before on a29cf858a and after on this branch. The alias column is identical to the raw column on both sides, so an alias was never a distinct case here.

Measurements
Query raw, before alias, before raw, after alias, after
sort <field> 500 fielddata 500 fielddata 4 rows 4 rows
where isnotnull(<field>) 200, total: 0 200, total: 0 200, total: 4 200, total: 4
stats count() by <field> 500 fielddata 500 fielddata 4 buckets 4 buckets
sort <field>, keyword control 4 rows 4 rows 4 rows 4 rows

The pre-fix plan for sort payload_alias emitted "sort":[{"payload":...}], naming the real field, which a guard that only saw the alias key could not have produced.

I also checked the dedup sort hint at AggregateAnalyzer.java:630, the one site carrying a raw user-typed name into a guard. An alias never reaches it, because the project defining the alias sits between the sort and the scan and the absorption does not see through it, measured as a sort clause present in the request body for the raw field and absent for the alias.

Added six cases to issues/5757.yml covering this, with a keyword-alias control so a decline is attributable to the type. Four of them go red with the guards reverted.

graphLookup was the only caller of the three-argument
PredicateAnalyzer.analyze, which hardcodes rowType and cluster to null.
No ScriptQueryExpression could be built there, so the catch rethrew
every unanalyzable filter as a RuntimeException.

That had two consequences. The binary refusals added by the previous
commit made isnotnull on a binary field unanalyzable at that site,
turning a 200 into a 500. Separately every graphLookup filter needing a
script was already failing on main, confirmed for n + 1 > 3,
abs(n - 3) < 1, length(name) = 1 and upper(name) = 'C' while a plain
n > 2 range filter succeeded.

Pass rowType and cluster, both already in scope at the call. The four
script filters now return correct edges, and n + 1 > 3 matches the
plain n > 2 form it reduces to.

Add eleven cases to issues/5757.yml over two new indices. One carries
field aliases over a binary and a keyword field, since an alias resolves
to its target before any pushdown site inspects the type and nothing
pinned that. The other carries a graph whose node c has no payload, so
isnotnull discriminates instead of matching every document. Reverting
the four source files from the previous commit reddens 23 of the 30
cases, and reverting only this one reddens exactly two.

Signed-off-by: Chayanin Noramuttha <cnoramut@amazon.com>
@github-actions

Copy link
Copy Markdown
Contributor

Persistent review updated to latest commit 55f67f5

@cnoramut

Copy link
Copy Markdown
Contributor Author

Chasing @ahkcs's alias question led me through the other filter sites, and it turned up a regression this PR would have shipped plus a pre-existing bug behind it. Both are fixed by the same two arguments, so flagging it here since it widens the diff beyond the binary guards.

graphLookup was the only filter site calling the three-argument PredicateAnalyzer.analyze, at CalciteEnumerableGraphLookup.java:255. That hardcodes rowType and cluster to null, so no ScriptQueryExpression could be built there and the catch rethrew every unanalyzable filter as a RuntimeException.

Two consequences.

First, the binary guards in this PR made isnotnull on a binary field unanalyzable at that site, so graphLookup ... filter=(isnotnull(<binary>)) went from 200 to 500. That is a regression introduced here.

Second, the same omission was already breaking every graphLookup filter that needs a script, with no binary field involved. Measured on a29cf858a.

filter=(n > 2)              200  [0, 1, 0, 1]
filter=(n + 1 > 3)          500  Cannot push down filter for graphLookup: Can't convert
filter=(abs(n - 3) < 1)     500  Cannot push down filter for graphLookup: Can't convert
filter=(length(name) = 1)   500  Cannot push down filter for graphLookup: Can't convert
filter=(upper(name) = 'C')  500  Cannot push down filter for graphLookup: Can't convert

So graphLookup ... filter=(upper(name) = 'C') has been unusable since filter support landed. I could not find an open issue for it.

The fix passes the two arguments, both already in scope at the call.

QueryBuilder filterQuery =
    PredicateAnalyzer.analyze(
        graphLookup.filter,
        schema,
        fieldTypes,
        graphLookup.getLookup().getRowType(),
        graphLookup.getCluster());

All four now return correct edges. n + 1 > 3 gives [0, 1, 0, 1], identical to the plain n > 2 form that always worked, which is the check that the script path agrees with the query path.

On scope, the binary half is not separable, since the graphLookup binary case only becomes a 500 because of the guards in this PR, and splitting would mean knowingly shipping a new 500 here. So I kept it in with tests for both halves. I can pull the script-path half into its own PR and open a follow-up issue for it if you would prefer that.

Tests: issues/5757.yml is at 30 cases, with a third index for the aliases and a fourth for graphLookup. Reverting the four original source files reddens 23. Reverting only the graphLookup file reddens exactly two, so each fix has coverage pinning it. Full :integ-test:integTest is 7304 tests with 0 failures, and spotlessCheck passes.

@ahkcs ahkcs added the bugFix label Sep 23, 2026
@ahkcs
ahkcs merged commit 5953571 into opensearch-project:main Sep 23, 2026
42 of 47 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Binary fields return 500 or silently wrong results when a pushdown references them

2 participants